這裡是「Three.js學習日誌」的第2篇,本篇的主旨是藉由描述一些簡單的webGL基礎,來做為引導three.js學習的鋪墊,這系列的文章假設讀者看得懂javascript,並且有Canvas 2D Context的相關知識
這邊我們要藉由用webGL
畫一個三角形,來完成「寫一個webGL的hello world」這項任務~
首先當然是要先建立canvas元素並取得webGL
的渲染環境
index.html
<!-- 這邊給的這些 height:100%;width:100% 目的是要讓canvas跟視窗永遠等高等寬-->
<html style="height:100%">
<body style="height:100%">
<canvas style="height:100%;width:100%"></canvas>
</body>
</html>
index.js
const cvs = document.querySelector('canvas');
const gl = cvs.getContext('webgl');
...
接著我們要設定canvas的長寬,讓他可以以正確的螢幕解析度來顯示圖像。
index.js
...
//由於我們在html已經設定讓canvas跟視窗永遠等高等寬了,
//所以這邊就算強制的讓width和height以螢幕像素比的比率倍增,也不會導致canvas超出螢幕畫面
//而是形成一種把像素強制壓縮的效果,可以藉由這樣來生成正常的螢幕解析度
gl.canvas.width = window.devicePixelRatio * gl.canvas.clientWidth;
gl.canvas.height = window.devicePixelRatio * gl.canvas.clientHeight;
...
gl.viewport
是一個webgl的特有方法,在webgl
中,座標的分布比較特別,相較於我們常見的x軸``y軸
都有正無限大到負無限大,webgl則是用+1~-1來表示,而gl.viewport
的用意則是把+1~-1映射到視窗上指定的範圍。
...
// 這邊我們把整個視窗大小設定為映射範圍
// gl.viewport前兩個參數是映射範圍的x,y座標,後兩個則是映射範圍的長寬
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
...
雖然大多數的坊間翻譯都是把 Shader
翻譯為 「著色器」,但我其實一直覺得這個譯名翻得很不到位。
Shader
主要指的是定義圖形渲染流水線規則的算法腳本 。
就是我們上一篇提到過的「定義構成了圖像的頂點座標,接著賦予這些頂點指定的顏色」
一般會分成Vertex Shader
(頂點著色器) 和 Fragment Shader
(片段著色器),這兩者分別的職責就是「定義構成圖像的頂點其座標」& 「賦予這些頂點顏色」。
而這裡的gl.createShader
的用意則是要建立空白的shader
腳本,並且可以傳入gl.VERTEX_SHADER
或 gl.FRAGMENT_SHADER
來決定到底是要產生哪一種空白腳本。
...
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
...
延伸閱讀: 著色器介紹 - 逍遙文工作室
產生完空白腳本之後則是要定義腳本內容所指向的來源,也就是腳本內容要放些什麼。
這邊shaderSource
的第二個參數是要以字串型別傳入整個Shader
的腳本內容。
一般狀況下,如果Shader
的內容不複雜,
我們可以用ES6
的Template literals
(就是反引號字串)
來撰寫腳本內容,但如果Shader
的內容偏長,也可以額外寫成一份html
格式的文件,然後用ajax
的方式引入。
或是如果有用webpack
,也可以搭配shader-loader 把腳本內容包裝成module
。
這種方法甚至還可以吃的到
VSCode
的語言highlight
這邊我們先用最簡單的Template literals
來導入Shader
腳本內容。
...
const vertexShaderScript = `
//這個是shader宣告變數的方式,意思近似聲明有一個attribute類型的變數存在,並且他是一組vec4變數
//attribute是shader與外界(也就是js)溝通的一個橋樑,js可以透過attribute把值傳進來給shader運用
//vec4有點像js的陣列,但長度固定為4
attribute vec4 a_position;
// 所有著色器都有一個main方法
void main() {
// gl_Position 是一個頂點著色器固有的變數(就像js在瀏覽器中也會有Math這樣的固有物件)
// 這邊我們把他的值指向我們前面宣告的a_position
gl_Position = a_position;
}
`
;
const fragmentShaderScript = `
// 這邊mediump是用來定義GPU計算浮點數時的精確度
// 片段著色器没有預設精確度,所以我們需要額外作設定
// 通常精確度會有三種值可以選(highp/mediump/lowp),但highp在某些系統下會有不支援的狀況,所以一般來說會選用mediump,代表“medium precision”(中等精度)
precision mediump float;
void main() {
// gl_FragColor 是一個片段著色器固有的變數
gl_FragColor = vec4(1, 0, 0.5, 1);
// 把所有的頂點都賦予“红紫色”的值,之所以是紅紫色,是因為前面三個數值(1,0,0.5) 換算成rgb,rgb通道都各乘以255,就會是 (255, 0, 127)
}
`;
gl.shaderSource(vertexShader, vertexShaderScript);
gl.shaderSource(fragmentShader, fragmentShaderScript);
...
把兩種Shader
腳本各自編譯成Binary Data,接下來的流程必需要用到。
...
gl.compileShader(vertexShader);
// 這邊是防呆用,假如Shader有寫錯,那就回報錯誤狀況
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.warn(`vertex shader error!`, gl.getShaderInfoLog(vertexShader));
}
gl.compileShader(fragmentShader);
// 同上防呆用
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
console.warn(`fragment shader error!`, gl.getShaderInfoLog(fragmentShader));
}
...
我們可以把WebGLProgram
視為前面提到的兩種Shader
整併起來的完全體。
也就是著色程序。
...
function createProgram(gl, vertexShader, fragmentShader) {
//建立空白的Program
const program = gl.createProgram();
//空白的Program連結上編譯好的shader
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
// 把program連接上webgl渲染環境
gl.linkProgram(program);
//防呆用,跟上一步驟類似
gl.validateProgram(program);
if (!gl.getProgramParameter(program, gl.VALIDATE_STATUS)) {
console.warn(`validate program failed`, gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return;
}
return program;
}
const program = createProgram(gl, vertexShader, fragmentShader);
...
我猜應該也會有人跟我一樣,第一次看到緩衝區(Buffer)這個概念都會覺得很疑惑。
這裡其實要稍微運用一點想像力~
在webgl Context
底下固定存在gl.ARRAY_BUFFER
和 gl.ELEMENT_ARRAY_BUFFER
這兩個空槽位。
而我們需要指定一個新建立的buffer
物件,把它放置到gl.ARRAY_BUFFER
這個空槽位上。
buffer
有點類似一個陣列,用來儲存頂點座標和色彩,...etc.的資料
之所以需要有buffer
,是因為我們接著需要一次性的向webgl Context
填充大量的數據。
...
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
...
這邊的bufferData
的第一個參數指定的是 ---
我們前面提到的webgl Context
底下固有的空槽位之一 --- gl.ARRAY_BUFFER
,
而不是去指向我們剛剛建立的
Buffer
。
第二個參數是把一組頂點座標資料以Float32Array
的格式傳進來。
最後第三個參數比較特別,他表示程序將如何使用儲存在Buffer
中的數據,有三種值可選
gl.STATIC_DRAW
:只會向緩衝區寫入一次數據gl.STREAM_DRAW
:只會向緩衝區寫入一次數據,然後繪製若干次gl.DYNAMIC_DRAW
:會向緩衝區多次寫入數據,並繪製多次這一步的操作,最終導致了position
這個陣列的資料被傳遞到了與gl.ARRAY_BUFFER
綁定在一起的positionBuffer
上。
...
const positions = [
0, 0,
0, 1.0,
1.0, 0,
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
...
這邊可以稍微注意一下,getAttribLocation
必須要在已經執行過Buffer
綁定的情況下才可以發動。
const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
這邊所謂的位置有點抽象,但這邊我們其實可以透過我們前面寫到的Shader
來做一個小實驗。
Shader
會導致gl.getAttribLocation(program, "a_position")
返回0
這個值。attribute vec4 a_position;
void main() {
gl_Position = a_position;
}
gl.getAttribLocation(program, "a_position")
一樣返回0
這個值,gl.getAttribLocation(program, "b_position")
卻會返回-1。attribute vec4 a_position;
attribute vec4 b_position;
void main() {
gl_Position = a_position;
}
gl.getAttribLocation(program, "a_position")
仍然會返回0
這個值,gl.getAttribLocation(program, "b_position")
這次卻會返回1。attribute vec4 a_position;
attribute vec4 b_position;
void main() {
gl_Position = a_position;
gl_Position = b_position;
}
所以這裡可以推測,getAttribLocation
返回的值會跟Shader
中宣告attribute
的順序,還有有沒有在main方法中調用有關。
有點像是Shader
中每宣告一個變數,就會產生一個附帶序號的位置欄位,這樣的感覺。
我們在前面有提到,Buffer
就像一個類陣列的物件,裡面儲存頂點座標,色彩等資訊。
這邊要特別注意一點,Buffer
儲存資料的方式其實是把所有資料統統混在一起的。
如果今天同時有頂點座標和色彩儲存在Buffer
裡面,內容就會像這樣:
// 偽code
//這邊只是以js的方式來說明,並不是真的要重新宣告一個positionBuffer
const positionBuffer = [
'頂點一x座標',
'頂點一y座標',
'頂點一色彩r通道值',
'頂點一色彩g通道值',
'頂點一色彩b通道值',
'頂點二x座標',
'頂點二y座標',
'頂點二色彩r通道值',
'頂點二色彩g通道值',
'頂點二色彩b通道值',
...
]
所以我們這邊需要去定義,每一個在Shader
中宣告的attribute
到底是要怎麼取用buffer中的資料。
但是因為我們這個hello world並沒有把每一點要設置的顏色對外開放。
而是在Shader
中把所有的gl_FragColor
都設定為紅紫色
所以其實Buffer
中並不會有色彩的資訊,也就是像下面這樣。
// 偽code
//這邊只是以js的方式來說明,並不是真的要重新宣告一個positionBuffer
const positionBuffer = [
'頂點一x座標',
'頂點一y座標',
'頂點二x座標',
'頂點二y座標',
...
]
所以接著我們要使用gl.vertexAttribPointer
來定義儲存在positionAttributeLocation
的這個位置上的attribute,其從Buffer
中取用資料的Pattern。
這邊有張圖,個人認為他很好的解釋了gl.vertexAttribPointer
這個方法到底在幹麻。
// 告訴屬性怎麼從positionBuffer中讀取數據 (ARRAY_BUFFER)
let size = 2; // 每兩個單位數據算一組頂點座標
let type = gl.FLOAT; // 每個單位的數據類型是32位浮點型
let normalize = false; // 不需要歸一化數據
let stride = 0; // stride代表的是一個定點一共會需要多少位的數據
// 如果是有色彩數據參雜的形況,一個頂點就只會有座標資料+色彩資料,也就是5位數據,那就給5 * Float32Array.BYTES_PER_ELEMENT,
// 但是如果沒有座標以外的數據參雜,也就是每組頂點都是只含有座標的2位數據,則應該給0(這種情形被稱為tightly packed)
let offset = 0; // 從Buffer的哪一個index作為起始點開始讀取
gl.vertexAttribPointer(
positionAttributeLocation, size, type, normalize, stride, offset)
開放定義儲存在positionAttributeLocation
的這個位置上的attribute為可被取用。
並且指定webgl Context使用前面建立的著色程序(Program)。
...
gl.enableVertexAttribArray(positionAttribLocation);
gl.useProgram(program);
...
這邊的gl.clearColor
就有點像2D Context
在做動畫的時候,每一幀都要清除畫布,不過這邊的做法是以特定的顏色填滿畫布,以達到清除的效果。
而下一行的gl.clear
裡面傳入的gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT
,這兩個是來自於底層的flag,分別用來代表"色彩緩衝區"和"深度緩衝區",概念上其實就跟我們前面提到的positionBuffer
很接近。
但是相較於positionBuffer
這個我們自己創造出來的緩衝區,這邊的gl.COLOR_BUFFER_BIT
和gl.DEPTH_BUFFER_BIT
是儲存在GPU
中的原生資訊,可以想像GPU
裡面其實存放著一個大陣列(當然這邊講陣列只是方便理解),裡面存滿一張canvas上所有使用到的色彩和深度資料。這邊一定會有人好奇深度又是一個什麼樣的概念,可以看這裡。
在最後gl.drawArrays
的部分,因為這個hello world要繪製的是三角形,所以gl.drawArrays
的第一個參數得給gl.TRIANGLES
。
其餘可選值有:
gl.POINTS
: 繪製多個點。gl.LINES
: 繪製一系列的線段。gl.LINE_STRIP
: 繪製一系列連接的線段。gl.LINE_LOOP
: 繪製多節線段,並且把最後一個線段連結回去繪製的原點,形成封閉線段。gl.TRIANGLES
: 繪製一系列的三角形。gl.TRIANGLE_STRIP
: 繪製一系列連接成帶狀的三角形。gl.TRIANGLE_FAN
: 繪製一系列連接成扇狀的三角形。gl.drawArrays
的第二個參數是offset
,也就是要略過多少個頂點,注意是多少個頂點,而不是多少位數。
gl.drawArrays
的最後一個參數則是頂點數量,因為是三角形所以給3。
順帶一提,如果想繪製的圖形是長方形的話要給6,並且傳進去positionBuffer的陣列也必須要有6組頂點,因為webgl裡面沒有提供長方形的繪製選項,所以必須要用兩個三角形拼接起來,也就是3+3=6
...
gl.clearColor(1, 1, 1, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3 );
Codepen傳送門:https://codepen.io/mizok/pen/KKRmdyB?editors=0010
Q1. 為什麼只是指定了三個頂點的顏色就可以長出來一個完整的三角形,這邊不需要像2D Context
一樣指定所有像素的顏色嗎?
對的,這邊確實只要指定三個頂點的顏色,就可以長出一個中間填滿色彩的三角形。
而若三個頂點顏色(vertex color)不同,則會生成一個內部具有漸層色彩的三角形。
Q2. 那如果是要畫一個很大的點要怎麼辦?
drawArray
要改成代入gl.POINTS
,並且vertex shader
的main方法裡面要加註這一行:gl_PointSize = 50.0;
Q3. 如果是要用webgl
來做動畫,那哪些部份是要放在requestAnimationFrame
的loop裡面的?
可以試試每一圈都重新執行
gl.bufferData
,gl.vertexAttribPointer
,gl.clearColor
,gl.clear
和gl.drawArrays
實際的Animation案例可以參考這個repo。
目前最完整且適合前端開發人員的webgl
中文教程只有這個
不過個人其實覺得這篇教程還是多少對菜鳥不太友善,比方說一些很細微的地方缺乏完善的講解(比方說vertexAttritubPointer
的 tightly packed狀況),所以還得多搭配自行google的能力。
其餘的話其實Stackoverflow上面就有很多有相關的討論,大陸的簡書上面也有不錯的教程(不過有點不完整)
這邊是只有列出我自己知道的。如果有研究的同好有其他的資源也歡迎提供QQ。